package paskal.audio.player;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.Line;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;

import javazoom.spi.PropertiesContainer;

import org.tritonus.share.sampled.TAudioFormat;
import org.tritonus.share.sampled.file.TAudioFileFormat;

public class CashedPlayer implements Controller, Runnable {
	public static final int UNKNOWN = -1;
	public static final int PLAYING = 0;
	public static final int PAUSED = 1;
	public static final int STOPPED = 2;
	public static final int OPENED = 3;
	public static final int SEEKING = 4;

	public static int EXTERNAL_BUFFER_SIZE = 1024 * 4;// 4096 * 4;
	public static int SKIP_INACCURACY_SIZE = 1200;

	private CashingThread cashingThread = null;

	protected Thread playbackThread = null;

	protected Object dataSource;
	protected int encodedLength = 0;
	protected AudioInputStream audioInputStream;
	protected AudioInputStream encodedAudioInputStream;
	protected AudioFileFormat audioFileFormat;
	protected SourceDataLine line;
	protected FloatControl gainControl;
	protected FloatControl volumeControl = null;
	protected String mixerName = null;
	private int lineCurrentBufferSize = -1;
	private int lineBufferSize = -1;
	private boolean pauseFlag = false;
	private boolean stopFlag = false;

	private int status = UNKNOWN;
	private PlayerListener listener = null;

	private int possibleDataSize = -1;

	public CashedPlayer() {
		dataSource = null;
		reset();
	}

	protected synchronized void reset() {
		setStatus(UNKNOWN);
		closeStream();
		notifyAll();
		audioFileFormat = null;
		encodedLength = 0;
		gainControl = null;
		volumeControl = null;
	}

	/**
	 * Add listener to be notified.
	 * 
	 * @param playerListener
	 */
	public void addPlayerListener(PlayerListener playerListener) {
		listener = playerListener;
	}

	/**
	 * Return registered listener.
	 * 
	 * @return
	 */
	public PlayerListener getListeners() {
		return listener;
	}

	/**
	 * Set SourceDataLine buffer size. It affects audio latency. (the delay
	 * between line.write(data) and real sound). Minimum value should be over
	 * 10000 bytes.
	 * 
	 * 
	 * @param size
	 *            -1 means maximum buffer size available.
	 */
	public void setLineBufferSize(int size) {
		lineBufferSize = size;
	}

	/**
	 * Return SourceDataLine buffer size.
	 * 
	 * @return -1 maximum buffer size.
	 */
	public int getLineBufferSize() {
		return lineBufferSize;
	}

	/**
	 * Return SourceDataLine current buffer size.
	 * 
	 * @return
	 */
	public int getLineCurrentBufferSize() {
		return lineCurrentBufferSize;
	}

	/**
	 * Returns Player status.
	 * 
	 * @return status
	 */
	public int getStatus() {
		return status;
	}

	/**
	 * Open file to play.
	 */
	public void open(File file) throws PlayerException {
		if (file != null) {
			dataSource = file;
			initAudioInputStream();
		}
	}

	/**
	 * Open URL to play.
	 */
	public void open(URL url) throws PlayerException {
		if (url != null) {
			try {
				reset();
				URLConnection connection = url.openConnection();
				if (connection != null) {
					int length = connection.getContentLength();

					if ((length != -1) && (length < 200 * 1024 * 1024)) {
						if (cashingThread != null) {
							cashingThread.disable();
						}
						if (listener != null)
							listener.onOpened(dataSource, null);
						cashingThread = new CashingThread(connection, this);
						cashingThread.start();
					} else {
						dataSource = url;
						initAudioInputStream();
					}
				}
			} catch (IOException e) {
				setStatus(STOPPED);
				e.printStackTrace();
			}
		}
	}

	/**
	 * Open inputstream to play.
	 */
	public void open(InputStream inputStream) throws PlayerException {
		if (inputStream != null) {
			dataSource = inputStream;
			initAudioInputStream();
		}
	}

	/**
	 * Inits AudioInputStream and AudioFileFormat from the data source.
	 * 
	 * @throws PlayerException
	 */
	protected void initAudioInputStream() {
		reset();
		try {
			notifyListener(PlayerEvent.OPENING, getPercentagePosition(), -1,
					dataSource);
			initAudioInputStream(dataSource);
			createLine();
			// Notify listeners with AudioFileFormat properties.
			Map<String, Object> properties = null;
			if (audioFileFormat instanceof TAudioFileFormat) {
				// Tritonus SPI compliant audio file format.
				// Clone the Map because it is not mutable.
				properties = deepCopy(((TAudioFileFormat) audioFileFormat)
						.properties());
			} else
				properties = new HashMap<String, Object>();
			// Add JavaSound properties.
			int length = (audioFileFormat.getByteLength() == -1) ? possibleDataSize
					: audioFileFormat.getByteLength();
			if (length > 0)
				properties.put("audio.length.bytes", length);
			if (audioFileFormat.getFrameLength() > 0)
				properties.put("audio.length.frames",
						audioFileFormat.getFrameLength());
			if (audioFileFormat.getType() != null)
				properties.put("audio.type",
						(audioFileFormat.getType().toString()));
			// Audio format.
			AudioFormat audioFormat = audioFileFormat.getFormat();
			if (audioFormat.getFrameRate() > 0)
				properties.put("audio.framerate.fps",
						audioFormat.getFrameRate());
			if (audioFormat.getFrameSize() > 0)
				properties.put("audio.framesize.bytes",
						audioFormat.getFrameSize());
			if (audioFormat.getSampleRate() > 0)
				properties.put("audio.samplerate.hz",
						audioFormat.getSampleRate());
			if (audioFormat.getSampleSizeInBits() > 0)
				properties.put("audio.samplesize.bits",
						audioFormat.getSampleSizeInBits());
			if (audioFormat.getChannels() > 0)
				properties.put("audio.channels", audioFormat.getChannels());
			if (audioFormat instanceof TAudioFormat) {
				// Tritonus SPI compliant audio format.
				Map<String, Object> addproperties = ((TAudioFormat) audioFormat)
						.properties();
				properties.putAll(addproperties);
			}
			// Add SourceDataLine
			properties.put("player.sourcedataline", line);
			if (listener != null)
				listener.onOpened(dataSource, properties);
			setStatus(OPENED);
			notifyListener(PlayerEvent.OPENED, getPercentagePosition(), -1,
					null);
		} catch (LineUnavailableException e) {
			e.printStackTrace();
		} catch (UnsupportedAudioFileException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	/**
	 * Inits Audio ressources.
	 */
	protected void initAudioInputStream(Object source)
			throws UnsupportedAudioFileException, IOException {
		if (dataSource instanceof URL) {
			possibleDataSize = ((URL) source).openConnection()
					.getContentLength();
			audioInputStream = AudioSystem.getAudioInputStream((URL) source);
			audioFileFormat = AudioSystem.getAudioFileFormat((URL) source);
		} else if (dataSource instanceof File) {
			audioInputStream = AudioSystem.getAudioInputStream((File) source);
			audioFileFormat = AudioSystem.getAudioFileFormat((File) source);
		} else if (dataSource instanceof InputStream) {
			audioInputStream = AudioSystem
					.getAudioInputStream((InputStream) source);
			audioFileFormat = AudioSystem
					.getAudioFileFormat((InputStream) source);
		}
	}

	/**
	 * Inits Audio ressources from AudioSystem.<br>
	 */
	protected void initLine() throws LineUnavailableException {
		if (line == null)
			createLine();
		if (!line.isOpen()) {
			openLine();
		} else {
			AudioFormat lineAudioFormat = line.getFormat();
			AudioFormat audioInputStreamFormat = audioInputStream == null ? null
					: audioInputStream.getFormat();
			if (!lineAudioFormat.equals(audioInputStreamFormat)) {
				line.close();
				openLine();
			}
		}
	}

	/**
	 * Inits a DateLine.<br>
	 * 
	 * We check if the line supports Gain controls.
	 * 
	 * From the AudioInputStream, i.e. from the sound file, we fetch information
	 * about the format of the audio data. These information include the
	 * sampling frequency, the number of channels and the size of the samples.
	 * There information are needed to ask JavaSound for a suitable output line
	 * for this audio file. Furthermore, we have to give JavaSound a hint about
	 * how big the internal buffer for the line should be. Here, we say
	 * AudioSystem.NOT_SPECIFIED, signaling that we don't care about the exact
	 * size. JavaSound will use some default value for the buffer size.
	 */
	protected void createLine() throws LineUnavailableException {
		if (line == null) {
			AudioFormat sourceFormat = audioInputStream.getFormat();
			int nSampleSizeInBits = sourceFormat.getSampleSizeInBits();
			if (nSampleSizeInBits <= 0)
				nSampleSizeInBits = 16;
			if ((sourceFormat.getEncoding() == AudioFormat.Encoding.ULAW)
					|| (sourceFormat.getEncoding() == AudioFormat.Encoding.ALAW))
				nSampleSizeInBits = 16;
			if (nSampleSizeInBits != 8)
				nSampleSizeInBits = 16;
			AudioFormat targetFormat = new AudioFormat(
					AudioFormat.Encoding.PCM_SIGNED,
					sourceFormat.getSampleRate(), nSampleSizeInBits,
					sourceFormat.getChannels(), sourceFormat.getChannels()
							* (nSampleSizeInBits / 8),
					sourceFormat.getSampleRate(), false);
			// Keep a reference on encoded stream to progress notification.
			encodedAudioInputStream = audioInputStream;
			try {
				// Get total length in bytes of the encoded stream.
				encodedLength = audioInputStream.available();
			} catch (IOException e) {
				System.err
						.println("Cannot get encodedAudioInputStream.available()");
				e.printStackTrace();
			}
			// Create decoded stream.
			audioInputStream = AudioSystem.getAudioInputStream(targetFormat,
					audioInputStream);
			AudioFormat audioFormat = audioInputStream.getFormat();
			DataLine.Info info = new DataLine.Info(SourceDataLine.class,
					audioFormat, AudioSystem.NOT_SPECIFIED);
			Mixer mixer = getMixer(mixerName);
			if (mixer != null) {
				line = (SourceDataLine) mixer.getLine(info);
			} else {
				line = (SourceDataLine) AudioSystem.getLine(info);
				mixerName = null;
			}
		}
	}

	/**
	 * Opens the line.
	 */
	protected void openLine() throws LineUnavailableException {
		if (line != null) {
			AudioFormat audioFormat = audioInputStream.getFormat();
			int buffersize = lineBufferSize;
			if (buffersize <= 0)
				buffersize = line.getBufferSize();
			lineCurrentBufferSize = buffersize;
			line.open(audioFormat, buffersize);
			// Is Gain Control supported ?
			if (line.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
				gainControl = (FloatControl) line
						.getControl(FloatControl.Type.MASTER_GAIN);
			}

			if (line.isControlSupported(FloatControl.Type.VOLUME)) {
				volumeControl = (FloatControl) line
						.getControl(FloatControl.Type.VOLUME);
			}
		}
	}

	/**
	 * Stops the playback.<br>
	 * 
	 * Player Status = STOPPED.<br>
	 * Thread should free Audio ressources.
	 */
	protected void stopPlayback() {
		stopFlag = false;
		if ((getStatus() == PLAYING) || (getStatus() == PAUSED)) {
			if (line != null) {
				line.flush();
				line.stop();
			}
			setStatus(STOPPED);
			notifyListener(PlayerEvent.STOPPED, getPercentagePosition(), -1,
					null);
			closeStream();
		}
	}

	/**
	 * Pauses the playback.<br>
	 * 
	 * Player Status = PAUSED.
	 */
	protected void pausePlayback() {
		pauseFlag = false;
		if (line != null) {
			if (line.isRunning()) {
				line.stop();
				line.flush();
				setStatus(PAUSED);
				notifyListener(PlayerEvent.PAUSED, getPercentagePosition(), -1,
						null);
			}
		}
	}

	/**
	 * Resumes the playback.<br>
	 * 
	 * Player Status = PLAYING.
	 */
	protected synchronized void resumePlayback() {
		if (getStatus() == PAUSED) {
			setStatus(PLAYING);
			if (line != null) {
				line.start();
			}
			notifyAll();
			notifyListener(PlayerEvent.RESUMED, getPercentagePosition(), -1,
					null);
		}
	}

	/**
	 * Starts playback.
	 */
	protected void startPlayback() throws PlayerException {
		if (getStatus() == STOPPED) {
			initAudioInputStream();
		}
		if (getStatus() == OPENED) {
			try {
				initLine();
			} catch (LineUnavailableException e) {
				throw new PlayerException(PlayerException.CANNOT_INIT_LINE, e);
			}
			if (line != null) {
				setStatus(PLAYING);
				playbackThread = new Thread(this, "PlaybackThread");
				playbackThread.start();
				line.start();
				notifyListener(PlayerEvent.PLAYING, getPercentagePosition(),
						-1, null);
			}
		}
	}

	/**
	 * Main loop.
	 * 
	 * Player Status == STOPPED || SEEKING => End of Thread + Freeing Audio
	 * Resources.<br>
	 * Player Status == PLAYING => Audio stream data sent to Audio line.<br>
	 * Player Status == PAUSED => Waiting for another status.
	 */
	@SuppressWarnings("unchecked")
	public void run() {
		int numberOfBytesRead = 0;
		byte[] data = new byte[EXTERNAL_BUFFER_SIZE];
		while ((getStatus() != STOPPED) && (getStatus() != SEEKING)
				&& (getStatus() != UNKNOWN)) {
			if (stopFlag)
				stopPlayback();
			if (getStatus() == PLAYING) {
				if (pauseFlag) {
					pausePlayback();
					try {
						synchronized (this) {
							while (getStatus() == PAUSED) {
								wait(10);
							}
						}
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
				try {
					if (audioInputStream != null) {
						numberOfBytesRead = audioInputStream.read(data, 0,
								data.length);
					} else {
						break;
					}
					if (numberOfBytesRead == -1) {
						if (line != null) {
							line.drain();
							line.stop();
							line.close();
							line = null;
						}
						notifyListener(PlayerEvent.EOM,
								getPercentagePosition(), -1, null);
						break;
					}
					if (line != null) {
						line.write(data, 0, numberOfBytesRead);
						if (listener != null) {
							Map<String, Object> properties = null;
							if (audioInputStream instanceof PropertiesContainer) {
								properties = ((PropertiesContainer) audioInputStream)
										.properties();
							}
							listener.onRunning(getPercentagePosition(),
									properties);
						}
					} else {
						break;
					}
				} catch (Exception e) {
					e.printStackTrace();
					setStatus(STOPPED);
					notifyListener(PlayerEvent.STOPPED,
							getPercentagePosition(), -1, null);
				}
			}
		}
	}

	/**
	 * Notify listeners about a PlayerEvent.
	 * 
	 * @param code
	 *            event code.
	 * @param position
	 *            in the stream when the event occurs.
	 */
	protected void notifyListener(final int code, final double position,
			final double value, final Object description) {
		if (listener != null) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					PlayerEvent event = new PlayerEvent(this, code, position,
							value, description);
					listener.onStateUpdated(event);
					// System.out.println(event);
				}
			}).start();
		}
	}

	protected double getPercentagePosition() {
		double position = 0;
		if (dataSource instanceof File) {
			try {
				if (encodedAudioInputStream != null) {
					double avaible = (double) encodedAudioInputStream
							.available() / encodedLength;
					position = 1 - avaible;
				}
			} catch (IOException e) {
				System.err
						.println("Cannot get encodedAudioInputStream.available()");
				e.printStackTrace();
			}
		}
		return position;
	}

	protected void closeStream() {
		try {
			// Close stream.
			if (audioInputStream != null) {
				encodedAudioInputStream = null;
				audioInputStream.close();
				audioInputStream = null;
			}
			// Free audio resources.
			if (line != null) {
				line.stop();
				line.flush();
				line.close();
				line = null;
			}
		} catch (IOException e) {
			System.err.println("Cannot close stream");
			e.printStackTrace();
		}
	}

	/**
	 * Returns true if Gain control is supported.
	 */
	public boolean hasGainControl() {
		if (gainControl == null) {
			if ((line != null)
					&& (line.isControlSupported(FloatControl.Type.MASTER_GAIN)))
				gainControl = (FloatControl) line
						.getControl(FloatControl.Type.MASTER_GAIN);
		}
		return (gainControl != null);
	}

	/**
	 * Returns true if Volume control is supported.
	 */
	public boolean hasVolumeControl() {
		if (volumeControl == null) {
			if ((line != null)
					&& (line.isControlSupported(FloatControl.Type.VOLUME))) {
				volumeControl = (FloatControl) line
						.getControl(FloatControl.Type.VOLUME);
			}
		}
		return (volumeControl != null);
	}

	/**
	 * Deep copy of a Map.
	 * 
	 * @param src
	 * @return
	 */
	protected Map<String, Object> deepCopy(Map<String, Object> src) {
		HashMap<String, Object> map = new HashMap<String, Object>();
		if (src != null) {
			for (Entry<String, Object> e : src.entrySet()) {
				map.put(e.getKey(), e.getValue());
			}
		}
		return map;
	}

	/**
	 * @see paskal.audio.player.Controller#play()
	 */
	public void play() throws PlayerException {
		if ((cashingThread != null) && (cashingThread.isEnabled())) {
			cashingThread.playAsapPlease();
		} else {
			startPlayback();
		}
	}

	/**
	 * @see paskal.audio.player.Controller#stop()
	 */
	public void stop() throws PlayerException {
		stopFlag = true;
	}

	/**
	 * @see paskal.audio.player.Controller#pause()
	 */
	public void pause() throws PlayerException {
		if (getStatus() == PLAYING) {
			pauseFlag = true;
		}
	}

	/**
	 * @see paskal.audio.player.Controller#resume()
	 */
	public void resume() throws PlayerException {
		resumePlayback();
	}

	/**
	 * Sets Gain value. Line should be opened before calling this method. Linear
	 * scale 0.0 <--> 1.0 Threshold Coef. : 1/2 to avoid saturation.
	 */
	public boolean setGain(double fGain) {
		if (hasGainControl()) {
			double minGainDB = gainControl.getMinimum();
			double ampGainDB = ((10.0f / 20.0f) * gainControl.getMaximum())
					- minGainDB;
			double cste = Math.log(10.0) / 20;
			double valueDB = minGainDB + (1 / cste)
					* Math.log(1 + (Math.exp(cste * ampGainDB) - 1) * fGain);
			gainControl.setValue((float) valueDB);
			notifyListener(PlayerEvent.GAIN, getPercentagePosition(), fGain,
					null);
			return true;
		} else {
			return false;
		}
	}

	@Override
	public void setVolume(float volume) throws PlayerException {
		if (hasVolumeControl()) {
			volumeControl.setValue(volume * volumeControl.getMaximum());
			notifyListener(PlayerEvent.VOLUME, getPercentagePosition(), volume,
					null);
		} else if (!setGain(volume))
			throw new PlayerException(
					PlayerException.VOLUME_CONTROL_NOT_SUPPORTED);
	}

	public List<String> getMixers() {
		ArrayList<String> mixers = new ArrayList<String>();
		Mixer.Info[] mInfos = AudioSystem.getMixerInfo();
		if (mInfos != null) {
			for (int i = 0; i < mInfos.length; i++) {
				Line.Info lineInfo = new Line.Info(SourceDataLine.class);
				Mixer mixer = AudioSystem.getMixer(mInfos[i]);
				if (mixer.isLineSupported(lineInfo)) {
					mixers.add(mInfos[i].getName());
				}
			}
		}
		return mixers;
	}

	public Mixer getMixer(String name) {
		Mixer mixer = null;
		if (name != null) {
			Mixer.Info[] mInfos = AudioSystem.getMixerInfo();
			if (mInfos != null) {
				for (int i = 0; i < mInfos.length; i++) {
					if (mInfos[i].getName().equals(name)) {
						mixer = AudioSystem.getMixer(mInfos[i]);
						break;
					}
				}
			}
		}
		return mixer;
	}

	public String getMixerName() {
		return mixerName;
	}

	public void setMixerName(String name) {
		mixerName = name;
	}

	/**
	 * @see paskal.audio.player.Controller#setPercentagePosition(int)
	 */
	@Override
	public void setPercentagePosition(double p) throws PlayerException {
		if (dataSource instanceof File) {
			if ((cashingThread != null) && (cashingThread.isEnabled())) {
				if (p < cashingThread.getLoadPercentage()) {
					skipBytes((long) (encodedLength * p));
				}
			} else {
				skipBytes((long) (encodedLength * p));
			}
		}
	}

	/**
	 * Skip bytes in the File input stream. It will skip N frames matching to
	 * bytes, so it will never skip given bytes length exactly.
	 * 
	 * @param bytes
	 * @return value>0 for File and value=0 for URL and InputStream
	 * @throws PlayerException
	 */
	protected long skipBytes(long bytes) throws PlayerException {
		long totalSkipped = 0;
		setStatus(SEEKING);
		long skipped = 0;
		try {
			initAudioInputStream();
			notifyListener(PlayerEvent.SEEKING, getPercentagePosition(), -1,
					null);
			if (audioInputStream != null) {
				// Loop until bytes are really skipped.
				while (totalSkipped < (bytes - SKIP_INACCURACY_SIZE)) {
					skipped = audioInputStream.skip(bytes - totalSkipped);
					if (skipped == 0)
						break;
					totalSkipped = totalSkipped + skipped;
					if (totalSkipped == -1)
						throw new PlayerException(
								PlayerException.SKIP_NOT_SUPPORTED);
				}
			}
			notifyListener(PlayerEvent.SEEKED, getPercentagePosition(), -1,
					null);
			play();
		} catch (IOException e) {
			throw new PlayerException(e);
		}
		return totalSkipped;
	}

	public PlayerListener getListener() {
		return listener;
	}

	public void clearCash() {
		if (cashingThread != null) {
			cashingThread.clearCash();
		}
	}

	public File getSource() {
		if (dataSource instanceof File) {
			return (File) dataSource;
		}
		return null;
	}

	public void setStatus(int status) {
		this.status = status;
	}
}